spb/autoscholar
Public
1import { AppError, audit, transition } from "@autoscholar/core";2import { publicationDecisions, researchRuns } from "@autoscholar/db";3import { eq } from "drizzle-orm";4import { z } from "zod";5import { handler, json, parseBody } from "@/lib/server/api";6import { db } from "@/lib/server/db";7import { requireAdmin } from "@/lib/server/session";89export const runtime = "nodejs";1011/**12 * Authorized override of a *soft* outcome only: moves quality_failed → ready_to_publish → awaiting_approval when every13 * failed gate is in the overridable set. Hard scientific blockers (fabrication, unverified citations, missing PDF,14 * synthetic/simulated artifacts, secrets) can never be overridden.15 */16const NON_OVERRIDABLE = new Set([17 "worker.real",18 "references.verified",19 "pdf.valid",20 "secrets.none",21 "attempt.final",22 "checksums.valid",23 "results.consistency",24 "data.provenance",25 "claims.traced",26 "contract.artifacts",27 "disclosure.present",28]);2930export const POST = handler(async (req, { params }) => {31 const sess = await requireAdmin(req, { mutation: true });32 const { runId } = (await params) as { runId: string };33 const body = await parseBody(req, z.object({ rationale: z.string().min(20).max(2000) }));34 const d = db();35 const [run] = await d.select().from(researchRuns).where(eq(researchRuns.id, runId)).limit(1);36 if (!run) throw new AppError("not_found", "run not found", 404);37 if (run.status !== "quality_failed") throw new AppError("conflict", `run is ${run.status}`, 409);38 const { qualityGateResults } = await import("@autoscholar/db");39 const { desc } = await import("drizzle-orm");40 const gates = await d41 .select()42 .from(qualityGateResults)43 .where(eq(qualityGateResults.runId, runId))44 .orderBy(desc(qualityGateResults.createdAt));45 const latest = gates.filter((g) => g.evaluationId === gates[0]?.evaluationId);46 const failedHard = latest.filter((g) => g.severity === "hard" && g.status === "fail");47 const blocked = failedHard.filter((g) => NON_OVERRIDABLE.has(g.gate));48 if (blocked.length)49 throw new AppError(50 "forbidden",51 `non-overridable gates failed: ${blocked.map((g) => g.gate).join(", ")}`,52 403,53 );54 await d.transaction(async (tx) => {55 await tx.insert(publicationDecisions).values({56 runId,57 evaluationId: latest[0]?.evaluationId ?? null,58 decision: "hold",59 decidedBy: `admin:${sess.userId}`,60 rationale: body.rationale,61 override: true,62 overrideRationale: body.rationale,63 gateSummary: { overriddenGates: failedHard.map((g) => g.gate) },64 });65 await transition(tx, runId, "ready_to_publish", {66 actor: `admin:${sess.userId}`,67 reason: `override: ${body.rationale}`,68 data: { overriddenGates: failedHard.map((g) => g.gate) },69 });70 await transition(tx, runId, "awaiting_approval", {71 actor: `admin:${sess.userId}`,72 reason: "override requires explicit publish action",73 });74 await audit(75 tx,76 { type: "admin", id: sess.userId, ip: sess.ip },77 "quality.override",78 { type: "run", id: runId },79 { rationale: body.rationale, gates: failedHard.map((g) => g.gate) },80 );81 });82 return json({ ok: true, status: "awaiting_approval" });83});84